A Notification System is a critical component in modern applications used to inform users about events such as new messages, payment updates, reminders, and alerts.
No notifications yet
Send a notification to see it here
For example, when a user receives a friend request or their order is shipped, the system should notify them via their preferred channels (like email or push notification) based on their settings.
In this chapter, we will explore the low-level design of a Notification System in detail.
Let’s start by clarifying the requirements:
Before diving into the design, it’s essential to clarify how the notification system is expected to behave. Asking the right questions helps uncover assumptions, define boundaries, and shape the system with confidence and clarity.
Candidate: What types of notifications should the system support?
Interviewer: Let's support three types for now: EMAIL, SMS, and PUSH.
Candidate: Should we support retry logic in case a notification fails?
Interviewer: Yes, retrying failed deliveries is important. Assume a simple retry mechanism with a maximum number of retries and a delay between attempts.
Candidate: Should sending be synchronous or asynchronous?
Interviewer: Asynchronous. The system should not block while sending notifications.
Candidate: Will the system support bulk notifications or just single notifications per request?
Interviewer: For this version, let’s keep it simple and only support sending one notification at a time.
After gathering the details, we can summarize the key system requirements.
Core entities are the fundamental building blocks of our system. We identify them by analyzing the functional requirements and highlighting the key nouns and responsibilities that naturally map to object-oriented abstractions such as classes, enums, or interfaces.
Let’s walk through the functional requirements and extract the relevant entities:
This implies the need for an entity to represent the message itself, which we will call Notification. This class will encapsulate all the data related to a single message, such as its content, subject, and a unique identifier. It also requires an entity to represent the destination user, which we'll call Recipient. This class will hold the user's ID and all their potential contact points, like an email address, phone number, or push notification token.
Since the set of channels is predefined and fixed, this is a perfect use case for an enum. We will define a NotificationType enum to represent the possible channels (EMAIL, SMS, PUSH). This provides type safety and makes the code more readable.
To hide the complexity of factory selection, decoration, and asynchronous execution, we introduce a central facade class, the NotificationService. This class will be the main entry point for clients. It will manage a thread pool to process sending requests off the main thread, select the correct gateway, wrap it with retry logic, and execute the send operation.
These core entities define the key abstractions of the notification system and will guide the structure of our low-level design and class diagrams.
This section details the design of each class identified previously, including their specific attributes and methods. We will also illustrate how these classes relate to one another and highlight the key design patterns that underpin our solution.
We can categorize our classes into enums, data-holding classes, and core classes that encapsulate the system's primary logic.
A type-safe enumeration to represent the different communication channels the system supports. It prevents errors from using invalid string literals and simplifies logic that depends on the channel type.
A data container that holds all necessary contact information for a single user.
Attributes:
Represents a single notification request.
Attributes:
Acts as the main entry point and Facade for the system. It hides the complexity of gateway creation, decoration, and asynchronous execution from the client.
Attributes:
Methods:
EmailGateway, SmsGateway, PushGateway, and RetryableGatewayDecorator are all implementations of the NotificationGateway interface.
The NotificationGateway interface and its concrete implementations (EmailGateway, SmsGateway, etc.) embody the Strategy Pattern.
Each gateway is a different "strategy" for sending a notification. The system can select and use the appropriate strategy at runtime based on the NotificationType.
The NotificationFactory implements a Simple Factory. It encapsulates the instantiation logic for the family of NotificationGateway objects, decoupling the NotificationService from the knowledge of which concrete gateway class to create.
The Notification.Builder inner class is used to construct Notification objects.
This pattern is ideal for objects with many parameters, especially optional ones, as it improves code readability and maintainability compared to telescoping constructors.
The RetryableGatewayDecorator is a prime example of the Decorator Pattern. It dynamically adds behavior (retry logic) to any NotificationGateway object without affecting other objects of the same class.
The NotificationService acts as a Facade. It provides a single, simplified interface to the client, hiding the complex underlying subsystem of factories, multiple gateway types, decorators, and asynchronous processing. The client only needs to interact with sendNotification().
Defines the types of notifications supported in the system
1class NotificationType(Enum):
2 EMAIL = "EMAIL"
3 SMS = "SMS"
4 PUSH = "PUSH"Represents the notification recipient.
1class Recipient:
2 def __init__(self, user_id: str, email: Optional[str] = None, phone_number: Optional[str] = None, push_token: Optional[str] = None):
3 self.user_id = user_id
4 self.email = email
5 self.phone_number = phone_number
6 self.push_token = push_token
7
8 def get_user_id(self) -> str:
9 return self.user_id
10
11 def get_email(self) -> Optional[str]:
12 return self.email
13
14 def get_phone_number(self) -> Optional[str]:
15 return self.phone_number
16
17 def get_push_token(self) -> Optional[str]:
18 return self.push_tokenemail, phoneNumber, and pushTokenOptional to represent possibly unavailable channelsEncapsulates the complete notification message to be delivered.
1class Notification:
2 def __init__(self, builder):
3 self.id = str(uuid.uuid4())
4 self.recipient = builder.recipient
5 self.type = builder.type
6 self.message = builder.message
7 self.subject = builder.subject
8
9 def get_id(self) -> str:
10 return self.id
11
12 def get_recipient(self) -> Recipient:
13 return self.recipient
14
15 def get_type(self) -> NotificationType:
16 return self.type
17
18 def get_message(self) -> str:
19 return self.message
20
21 def get_subject(self) -> str:
22 return self.subject
23
24 class Builder:
25 def __init__(self, recipient: Recipient, notification_type: NotificationType):
26 self.recipient = recipient
27 self.type = notification_type
28 self.message = None
29 self.subject = None
30
31 def message(self, message: str):
32 self.message = message
33 return self
34
35 def subject(self, subject: str):
36 self.subject = subject
37 return self
38
39 def build(self):
40 return Notification(self)id, recipient, type, subject, and messageDefines a contract for all notification delivery mechanisms. Each concrete implementation sends notifications through a specific channel (Email, SMS, or Push)
1class NotificationGateway(ABC):
2 @abstractmethod
3 def send(self, notification: Notification):
4 pass
5
6
7class EmailGateway(NotificationGateway):
8 def send(self, notification: Notification):
9 email = notification.get_recipient().get_email()
10 if email is None:
11 raise ValueError("Email address is required for EMAIL notification.")
12
13 print("--- Sending EMAIL ---")
14 print(f"To: {email}")
15 print(f"Subject: {notification.get_subject()}")
16 print(f"Body: {notification.get_message()}")
17 print("---------------------\n")
18
19
20class PushGateway(NotificationGateway):
21 def send(self, notification: Notification):
22 token = notification.get_recipient().get_push_token()
23 if token is None:
24 raise ValueError("Push token is required for PUSH notification.")
25
26 print("--- Sending PUSH Notification ---")
27 print(f"To Device Token: {token}")
28 print(f"Title: {notification.get_subject()}")
29 print(f"Body: {notification.get_message()}")
30 print("---------------------------------\n")
31
32
33class SmsGateway(NotificationGateway):
34 def send(self, notification: Notification):
35 phone = notification.get_recipient().get_phone_number()
36 if phone is None:
37 raise ValueError("Phone number is required for SMS notification.")
38
39 print("--- Sending SMS ---")
40 print(f"To: {phone}")
41 print(f"Message: {notification.get_message()}")
42 print("-------------------\n")Implements the Factory Pattern to instantiate appropriate gateway based on NotificationType.
1class NotificationFactory:
2 _gateway_map: Dict[NotificationType, NotificationGateway] = {}
3
4 @classmethod
5 def create_gateway(cls, notification_type: NotificationType) -> NotificationGateway:
6 if notification_type in cls._gateway_map:
7 return cls._gateway_map[notification_type]
8
9 gateway = None
10
11 if notification_type == NotificationType.EMAIL:
12 gateway = EmailGateway()
13 elif notification_type == NotificationType.SMS:
14 gateway = SmsGateway()
15 elif notification_type == NotificationType.PUSH:
16 gateway = PushGateway()
17
18 cls._gateway_map[notification_type] = gateway
19 return gatewayUses caching (gatewayMap) to reuse gateway instances
Implements the Decorator Pattern to enhance any NotificationGateway with retry logic.
1class RetryableGatewayDecorator(NotificationGateway):
2 def __init__(self, wrapped_gateway: NotificationGateway, max_retries: int, retry_delay_millis: int):
3 self.wrapped_gateway = wrapped_gateway
4 self.max_retries = max_retries
5 self.retry_delay_millis = retry_delay_millis
6
7 def send(self, notification: Notification):
8 attempt = 0
9 while attempt < self.max_retries:
10 try:
11 self.wrapped_gateway.send(notification)
12 return # Success
13 except Exception as e:
14 attempt += 1
15 print(f"Error: Attempt {attempt} failed for notification {notification.get_id()}. Retrying...")
16 if attempt >= self.max_retries:
17 print(str(e))
18 raise Exception(f"Failed to send notification after {self.max_retries} attempts.") from e
19 time.sleep(self.retry_delay_millis / 1000.0)The Facade and Executor-backed asynchronous orchestrator of the system.
1class NotificationService:
2 def __init__(self, pool_size: int):
3 self.executor = ThreadPoolExecutor(max_workers=pool_size)
4
5 def send_notification(self, notification: Notification):
6 def send_task():
7 gateway = RetryableGatewayDecorator(
8 NotificationFactory.create_gateway(notification.get_type()),
9 3,
10 1000
11 )
12 try:
13 gateway.send(notification)
14 except Exception as e:
15 print(f"Exception while sending notification: {e}")
16
17 self.executor.submit(send_task)
18
19 def shutdown(self):
20 self.executor.shutdown()ExecutorService) for parallel deliveryDemonstrates the usage of the notification system.
1def main():
2 # 1. Setup the notification service
3 notification_service = NotificationService(10)
4
5 # 2. Define recipients
6 recipient1 = Recipient("user123", "[email protected]", None, "pushToken123")
7 recipient2 = Recipient("user456", None, "+15551234567", None)
8
9 # 3. Send various notifications using the Facade (NotificationService)
10
11 # Scenario 1: Send a welcome email
12 welcome_email = (Notification.Builder(recipient1, NotificationType.EMAIL)
13 .subject("Welcome!")
14 .message("Welcome to notification system")
15 .build())
16 notification_service.send_notification(welcome_email)
17
18 # Scenario 2: Send a direct push notification
19 push_notification = (Notification.Builder(recipient1, NotificationType.PUSH)
20 .subject("New Message")
21 .message("You have a new message from Jane.")
22 .build())
23 notification_service.send_notification(push_notification)
24
25 # Scenario 3: Send order confirmation SMS
26 order_sms = (Notification.Builder(recipient2, NotificationType.SMS)
27 .message("Your order for Digital Clock is confirmed")
28 .build())
29 notification_service.send_notification(order_sms)
30
31 # Wait for a moment to allow the queue processor to work
32 time.sleep(1)
33
34 # 4. Shutdown the system
35 print("\nShutting down the notification system...")
36 notification_service.shutdown()
37 print("System shut down successfully.")
38
39
40if __name__ == "__main__":
41 main()Which core class is responsible for orchestrating the sending of notifications and managing asynchronous delivery in the notification system?
factory can be written in multiple ways, so its not wrong